feat(ui): appset list page and filters (#25837)

Signed-off-by: Peter Jiang <peterjiang823@gmail.com>
This commit is contained in:
Peter Jiang
2026-01-30 10:40:38 -08:00
committed by GitHub
parent b912405c16
commit 72085781dc
13 changed files with 1297 additions and 632 deletions

View File

@@ -32,6 +32,8 @@ type Routes = {[path: string]: {component: React.ComponentType<RouteComponentPro
const routes: Routes = {
'/login': {component: login.component as any, noLayout: true},
'/applications': {component: applications.component},
// TODO: Uncomment when ApplicationSet details page is fully implemented
// '/applicationsets': {component: applications.component},
'/settings': {component: settings.component},
'/user-info': {component: userInfo.component},
'/help': {component: help.component}

View File

@@ -0,0 +1,164 @@
import {DropDownMenu, Tooltip} from 'argo-ui';
import * as React from 'react';
import Moment from 'react-moment';
import {Cluster} from '../../../shared/components';
import {ContextApis} 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 {ApplicationsLabels} from './applications-labels';
import {ApplicationsSource} from './applications-source';
import {services} from '../../../shared/services';
import {ViewPreferences} from '../../../shared/services';
export interface ApplicationTableRowProps {
app: models.Application;
selected: boolean;
pref: ViewPreferences;
ctx: ContextApis;
syncApplication: (appName: string, appNamespace: string) => void;
refreshApplication: (appName: string, appNamespace: string) => void;
deleteApplication: (appName: string, appNamespace: string) => void;
}
export const ApplicationTableRow = ({app, selected, pref, ctx, syncApplication, refreshApplication, deleteApplication}: ApplicationTableRowProps) => {
const favList = pref.appList.favoritesAppList || [];
const healthStatus = app.status.health.status;
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
const source = getAppDefaultSource(app);
const handleFavoriteToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (favList?.includes(app.metadata.name)) {
favList.splice(favList.indexOf(app.metadata.name), 1);
} else {
favList.push(app.metadata.name);
}
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
};
const handleExternalLinkClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (linkInfo.isExternal) {
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
} else {
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
}
};
return (
<div className={`argo-table-list__row applications-list__entry applications-list__entry--health-${healthStatus} ${selected ? 'applications-tiles__selected' : ''}`}>
<div
className={`row applications-list__table-row ${app.status.sourceHydrator?.currentOperation ? 'applications-table-row--with-hydrator' : ''}`}
onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {}, {event: e})}>
{/* First column: Favorite, URLs, Project, Name */}
<div className='columns small-4'>
<div className='row'>
<div className='columns small-2'>
<div>
<Tooltip content={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
<button onClick={handleFavoriteToggle}>
<i
className={favList?.includes(app.metadata.name) ? 'fas fa-star' : 'far fa-star'}
style={{
cursor: 'pointer',
marginRight: '7px',
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
}}
/>
</button>
</Tooltip>
<ApplicationURLs urls={app.status.summary?.externalURLs} />
</div>
</div>
<div className='show-for-xxlarge columns small-4'>Project:</div>
<div className='columns small-12 xxlarge-6'>{app.spec.project}</div>
</div>
<div className='row'>
<div className='columns small-2' />
<div className='show-for-xxlarge columns small-4'>Name:</div>
<div className='columns small-12 xxlarge-6'>
<Tooltip
content={
<>
{app.metadata.name}
<br />
<Moment fromNow={true} ago={true}>
{app.metadata.creationTimestamp}
</Moment>
</>
}>
<span>{app.metadata.name}</span>
</Tooltip>
<button
onClick={handleExternalLinkClick}
style={{marginLeft: '0.5em'}}
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(app) || 'none'}`}>
<i className='fa fa-external-link-alt' />
</button>
</div>
</div>
</div>
{/* Second column: Source and Destination */}
<div className='columns small-6'>
<div className='row'>
<div className='show-for-xxlarge columns small-2'>Source:</div>
<div className='columns small-12 xxlarge-10 applications-table-source' style={{position: 'relative'}}>
<div className='applications-table-source__link'>
<ApplicationsSource source={source} />
</div>
<div className='applications-table-source__labels'>
<ApplicationsLabels app={app} />
</div>
</div>
</div>
<div className='row'>
<div className='show-for-xxlarge columns small-2'>Destination:</div>
<div className='columns small-12 xxlarge-10'>
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />/{app.spec.destination.namespace}
</div>
</div>
</div>
{/* Third column: Status and Actions */}
<div className='columns small-2'>
<AppUtils.HealthStatusIcon state={app.status.health} /> <span>{app.status.health.status}</span> <br />
{app.status.sourceHydrator?.currentOperation && (
<>
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
<span>{app.status.sourceHydrator.currentOperation.phase}</span> <br />
</>
)}
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} />
<span>{app.status.sync.status}</span> <OperationState app={app} quiet={true} />
<DropDownMenu
anchor={() => (
<button className='argo-button argo-button--light argo-button--lg argo-button--short'>
<i className='fa fa-ellipsis-v' />
</button>
)}
items={[
{
title: 'Sync',
iconClassName: 'fa fa-fw fa-sync',
action: () => syncApplication(app.metadata.name, app.metadata.namespace)
},
{
title: 'Refresh',
iconClassName: 'fa fa-fw fa-redo',
action: () => refreshApplication(app.metadata.name, app.metadata.namespace)
},
{
title: 'Delete',
iconClassName: 'fa fa-fw fa-times-circle',
action: () => deleteApplication(app.metadata.name, app.metadata.namespace)
}
]}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,267 @@
import {Tooltip} from 'argo-ui';
import * as classNames from 'classnames';
import * as React from 'react';
import {Cluster} from '../../../shared/components';
import {ContextApis, 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 {services} from '../../../shared/services';
import {ViewPreferences} from '../../../shared/services';
export interface ApplicationTileProps {
app: models.Application;
selected: boolean;
pref: ViewPreferences;
ctx: ContextApis;
tileRef?: React.RefObject<HTMLDivElement>;
syncApplication: (appName: string, appNamespace: string) => void;
refreshApplication: (appName: string, appNamespace: string) => void;
deleteApplication: (appName: string, appNamespace: string) => void;
}
export const ApplicationTile = ({app, selected, pref, ctx, tileRef, syncApplication, refreshApplication, deleteApplication}: ApplicationTileProps) => {
const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
const favList = pref.appList.favoritesAppList || [];
const source = getAppDefaultSource(app);
const isOci = source?.repoURL?.startsWith('oci://');
const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown';
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
const healthStatus = app.status.health.status;
const handleFavoriteToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (favList?.includes(app.metadata.name)) {
favList.splice(favList.indexOf(app.metadata.name), 1);
} else {
favList.push(app.metadata.name);
}
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
};
const handleExternalLinkClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (linkInfo.isExternal) {
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
} else {
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
}
};
return (
<div
ref={tileRef}
className={`argo-table-list__row applications-list__entry applications-list__entry--health-${healthStatus} ${selected ? 'applications-tiles__selected' : ''}`}>
<div className='row applications-tiles__wrapper' onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {view: pref.appDetails.view}, {event: e})}>
<div className={`columns small-12 applications-list__info qe-applications-list-${AppUtils.appInstanceName(app)} applications-tiles__item`}>
{/* Header row with icon, title, and action buttons */}
<div className='row'>
<div className={app.status.summary?.externalURLs?.length > 0 ? 'columns small-10' : 'columns small-11'}>
<i className={'icon argo-icon-' + (source?.chart != null ? 'helm' : isOci ? 'oci applications-tiles__item__small' : 'git')} />
<Tooltip content={AppUtils.appInstanceName(app)}>
<span className='applications-list__title'>{AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)}</span>
</Tooltip>
</div>
<div className={app.status.summary?.externalURLs?.length > 0 ? 'columns small-2' : 'columns small-1'}>
<div className='applications-list__external-link'>
<ApplicationURLs urls={app.status.summary?.externalURLs} />
<button onClick={handleExternalLinkClick} title={getManagedByURL(app) ? `Managed by: ${getManagedByURL(app)}` : 'Open application'}>
<i className='fa fa-external-link-alt' />
</button>
<button
title={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
className='large-text-height'
onClick={handleFavoriteToggle}>
<i
className={favList?.includes(app.metadata.name) ? 'fas fa-star fa-lg' : 'far fa-star fa-lg'}
style={{
cursor: 'pointer',
margin: '-1px 0px 0px 7px',
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
}}
/>
</button>
</div>
</div>
</div>
{/* Project row */}
<div className='row'>
<div className='columns small-3' title='Project:'>
Project:
</div>
<div className='columns small-9'>{app.spec.project}</div>
</div>
{/* Labels row */}
<div className='row'>
<div className='columns small-3' title='Labels:'>
Labels:
</div>
<div className='columns small-9'>
<Tooltip
zIndex={4}
content={
<div>
{Object.keys(app.metadata.labels || {})
.map(label => ({label, value: app.metadata.labels[label]}))
.map(item => (
<div key={item.label}>
{item.label}={item.value}
</div>
))}
</div>
}>
<span>
{Object.keys(app.metadata.labels || {})
.map(label => `${label}=${app.metadata.labels[label]}`)
.join(', ')}
</span>
</Tooltip>
</div>
</div>
{/* Status row */}
<div className='row'>
<div className='columns small-3' title='Status:'>
Status:
</div>
<div className='columns small-9' qe-id='applications-tiles-health-status'>
<AppUtils.HealthStatusIcon state={app.status.health} /> {app.status.health.status}
&nbsp;
{app.status.sourceHydrator?.currentOperation && (
<>
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
{app.status.sourceHydrator.currentOperation.phase}
&nbsp;
</>
)}
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} /> {app.status.sync.status}
&nbsp;
<OperationState app={app} quiet={true} />
</div>
</div>
{/* Repository row */}
<div className='row'>
<div className='columns small-3' title='Repository:'>
Repository:
</div>
<div className='columns small-9'>
<Tooltip content={source?.repoURL || ''} zIndex={4}>
<span>{source?.repoURL}</span>
</Tooltip>
</div>
</div>
{/* Target Revision row */}
<div className='row'>
<div className='columns small-3' title='Target Revision:'>
Target Revision:
</div>
<div className='columns small-9'>{targetRevision}</div>
</div>
{/* Path row (conditional) */}
{source?.path && (
<div className='row'>
<div className='columns small-3' title='Path:'>
Path:
</div>
<div className='columns small-9'>{source?.path}</div>
</div>
)}
{/* Chart row (conditional) */}
{source?.chart && (
<div className='row'>
<div className='columns small-3' title='Chart:'>
Chart:
</div>
<div className='columns small-9'>{source?.chart}</div>
</div>
)}
{/* Destination row */}
<div className='row'>
<div className='columns small-3' title='Destination:'>
Destination:
</div>
<div className='columns small-9'>
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />
</div>
</div>
{/* Namespace row */}
<div className='row'>
<div className='columns small-3' title='Namespace:'>
Namespace:
</div>
<div className='columns small-9'>{app.spec.destination.namespace}</div>
</div>
{/* Created At row */}
<div className='row'>
<div className='columns small-3' title='Age:'>
Created At:
</div>
<div className='columns small-9'>{AppUtils.formatCreationTimestamp(app.metadata.creationTimestamp)}</div>
</div>
{/* Last Sync row (conditional) */}
{app.status.operationState && (
<div className='row'>
<div className='columns small-3' title='Last sync:'>
Last Sync:
</div>
<div className='columns small-9'>{AppUtils.formatCreationTimestamp(app.status.operationState.finishedAt || app.status.operationState.startedAt)}</div>
</div>
)}
{/* Action buttons */}
<div className='row applications-tiles__actions'>
<div className='columns applications-list__entry--actions'>
<a
className='argo-button argo-button--base'
qe-id='applications-tiles-button-sync'
onClick={e => {
e.stopPropagation();
syncApplication(app.metadata.name, app.metadata.namespace);
}}>
<i className='fa fa-sync' /> Sync
</a>
&nbsp;
<Tooltip className='custom-tooltip' content={'Refresh'}>
<a
className='argo-button argo-button--base'
qe-id='applications-tiles-button-refresh'
{...AppUtils.refreshLinkAttrs(app)}
onClick={e => {
e.stopPropagation();
refreshApplication(app.metadata.name, app.metadata.namespace);
}}>
<i className={classNames('fa fa-redo', {'status-icon--spin': AppUtils.isAppRefreshing(app)})} />{' '}
<span className='show-for-xxlarge'>Refresh</span>
</a>
</Tooltip>
&nbsp;
<Tooltip className='custom-tooltip' content={'Delete'}>
<a
className='argo-button argo-button--base'
qe-id='applications-tiles-button-delete'
onClick={e => {
e.stopPropagation();
deleteApplication(app.metadata.name, app.metadata.namespace);
}}>
<i className='fa fa-times-circle' /> <span className='show-for-xxlarge'>Delete</span>
</a>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -5,6 +5,7 @@ import {Context} from '../../../shared/context';
import {
Application,
ApplicationDestination,
ApplicationSet,
Cluster,
HealthStatusCode,
HealthStatuses,
@@ -14,10 +15,10 @@ import {
SyncStatusCode,
SyncStatuses
} from '../../../shared/models';
import {AppsListPreferences, services} from '../../../shared/services';
import {AppsListPreferences, AppSetsListPreferences, services} from '../../../shared/services';
import {Filter, FiltersGroup} from '../filter/filter';
import {createMetadataSelector} from '../selectors';
import {ComparisonStatusIcon, getAppDefaultSource, HealthStatusIcon, getOperationStateTitle} from '../utils';
import {ComparisonStatusIcon, getAppDefaultSource, getAppSetHealthStatus, HealthStatusIcon, getOperationStateTitle} from '../utils';
import {formatClusterQueryParam} from '../../../shared/utils';
import {COLORS} from '../../../shared/components/colors';
@@ -26,18 +27,29 @@ export interface FilterResult {
sync: boolean;
autosync: boolean;
health: boolean;
namespaces: boolean;
clusters: boolean;
namespaces: boolean;
operation: boolean;
annotations: boolean;
favourite: boolean;
labels: boolean;
}
export interface ApplicationSetFilterResult {
health: boolean;
favourite: boolean;
labels: boolean;
annotations: boolean;
operation: boolean;
}
export interface FilteredApp extends Application {
isAppOfAppsPattern?: boolean;
filterResult: FilterResult;
}
export interface ApplicationSetFilteredApp extends ApplicationSet {
filterResult: ApplicationSetFilterResult;
}
export function getAutoSyncStatus(syncPolicy?: SyncPolicy) {
if (!syncPolicy || !syncPolicy.automated || syncPolicy.automated.enabled === false) {
return 'Disabled';
@@ -45,7 +57,7 @@ export function getAutoSyncStatus(syncPolicy?: SyncPolicy) {
return 'Enabled';
}
export function getFilterResults(applications: Application[], pref: AppsListPreferences): FilteredApp[] {
export function getAppFilterResults(applications: Application[], pref: AppsListPreferences): FilteredApp[] {
const labelSelector = createMetadataSelector(pref.labelsFilter || []);
const annotationSelector = createMetadataSelector(pref.annotationsFilter || []);
@@ -77,6 +89,19 @@ export function getFilterResults(applications: Application[], pref: AppsListPref
}));
}
export function getAppSetFilterResults(appSets: ApplicationSet[], pref: AppSetsListPreferences): ApplicationSetFilteredApp[] {
const labelSelector = createMetadataSelector(pref.labelsFilter || []);
return appSets.map(appSet => ({
...appSet,
filterResult: {
health: pref.healthFilter.length === 0 || pref.healthFilter.includes(getAppSetHealthStatus(appSet)),
favourite: !pref.showFavorites || (pref.favoritesAppList && pref.favoritesAppList.includes(appSet.metadata.name)),
labels: pref.labelsFilter.length === 0 || labelSelector(appSet.metadata.labels)
}
}));
}
const optionsFrom = (options: string[], filter: string[]) => {
return options
.filter(s => filter.indexOf(s) === -1)
@@ -85,7 +110,8 @@ const optionsFrom = (options: string[], filter: string[]) => {
});
};
interface AppFilterProps {
// Props for Application filters
export interface AppFilterProps {
apps: FilteredApp[];
pref: AppsListPreferences;
onChange: (newPrefs: AppsListPreferences) => void;
@@ -93,6 +119,15 @@ interface AppFilterProps {
collapsed?: boolean;
}
// Props for ApplicationSet filters
export interface AppSetFilterProps {
apps: ApplicationSetFilteredApp[];
pref: AppSetsListPreferences;
onChange: (newPrefs: AppSetsListPreferences) => void;
children?: React.ReactNode;
collapsed?: boolean;
}
const getCounts = (apps: FilteredApp[], filterType: keyof FilterResult, filter: (app: Application) => string, init?: string[]) => {
const map = new Map<string, number>();
if (init) {
@@ -105,6 +140,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<string, number>();
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 => {
@@ -116,6 +163,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) => (
<Filter
label='SYNC STATUS'
@@ -133,7 +197,7 @@ const SyncFilter = (props: AppFilterProps) => (
/>
);
const HealthFilter = (props: AppFilterProps) => (
const AppHealthFilter = (props: AppFilterProps) => (
<Filter
label='HEALTH STATUS'
selected={props.pref.healthFilter}
@@ -150,7 +214,24 @@ const HealthFilter = (props: AppFilterProps) => (
/>
);
const LabelsFilter = (props: AppFilterProps) => {
const AppSetHealthFilter = (props: AppSetFilterProps) => (
<Filter
label='HEALTH STATUS'
selected={props.pref.healthFilter}
setSelected={s => props.onChange({...props.pref, healthFilter: s})}
options={getAppSetOptions(
props.apps,
'health',
app => getAppSetHealthStatus(app),
Object.keys(HealthStatuses),
s => (
<HealthStatusIcon state={{status: s as HealthStatusCode, message: ''}} noSpin={true} />
)
)}
/>
);
const LabelsFilter = (props: {apps: Array<{metadata: {labels?: {[key: string]: string}}}>; pref: {labelsFilter: string[]}; onChange: (labelsFilter: string[]) => void}) => {
const labels = new Map<string, Set<string>>();
props.apps
.filter(app => app.metadata && app.metadata.labels)
@@ -173,7 +254,7 @@ const LabelsFilter = (props: AppFilterProps) => {
return {label: s};
});
return <Filter label='LABELS' selected={props.pref.labelsFilter} setSelected={s => props.onChange({...props.pref, labelsFilter: s})} field={true} options={labelOptions} />;
return <Filter label='LABELS' selected={props.pref.labelsFilter} setSelected={s => props.onChange(s)} field={true} options={labelOptions} />;
};
const AnnotationsFilter = (props: AppFilterProps) => {
@@ -278,11 +359,11 @@ const NamespaceFilter = (props: AppFilterProps) => {
);
};
const FavoriteFilter = (props: AppFilterProps) => {
const FavoriteFilter = (props: {pref: {showFavorites?: boolean}; onChange: (showFavorites: boolean) => void}) => {
const ctx = React.useContext(Context);
const onChange = (val: boolean) => {
ctx.navigation.goto('.', {showFavorites: val}, {replace: true});
services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val}});
props.onChange(val);
};
return (
<div
@@ -384,11 +465,11 @@ const OperationFilter = (props: AppFilterProps) => (
export const ApplicationsFilter = (props: AppFilterProps) => {
return (
<FiltersGroup title='Application filters' content={props.children} collapsed={props.collapsed}>
<FavoriteFilter {...props} />
<FavoriteFilter pref={props.pref} onChange={val => services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val}})} />
<SyncFilter {...props} />
<HealthFilter {...props} />
<AppHealthFilter {...props} />
<OperationFilter {...props} />
<LabelsFilter {...props} />
<LabelsFilter apps={props.apps} pref={props.pref} onChange={labelsFilter => props.onChange({...props.pref, labelsFilter})} />
<AnnotationsFilter {...props} />
<ProjectFilter {...props} />
<ClusterFilter {...props} />
@@ -397,3 +478,13 @@ export const ApplicationsFilter = (props: AppFilterProps) => {
</FiltersGroup>
);
};
export const AppSetsFilter = (props: AppSetFilterProps) => {
return (
<FiltersGroup title='ApplicationSet filters' content={props.children} collapsed={props.collapsed}>
<FavoriteFilter pref={props.pref} onChange={val => services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val} as AppsListPreferences})} />
<AppSetHealthFilter {...props} />
<LabelsFilter apps={props.apps} pref={props.pref} onChange={labelsFilter => props.onChange({...props.pref, labelsFilter})} />
</FiltersGroup>
);
};

View File

@@ -9,13 +9,13 @@ 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, AppSetsListPreferences, 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 {ApplicationsStatusBar} from './applications-status-bar';
import {ApplicationsFilter, AppSetsFilter, FilteredApp, ApplicationSetFilteredApp, getAppFilterResults, getAppSetFilterResults} from './applications-filter';
import {AppsStatusBar, AppSetsStatusBar} from './applications-status-bar';
import {ApplicationsSummary} from './applications-summary';
import {ApplicationsTable} from './applications-table';
import {ApplicationTiles} from './applications-tiles';
@@ -175,18 +175,36 @@ 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 filterApplications(applications: models.Application[], pref: AppsListPreferences, search: string): {filteredApps: models.Application[]; filterResults: FilteredApp[]} {
const processedApps = applications.map(app => {
let isAppOfAppsPattern = false;
for (const resource of app.status.resources) {
if (resource.kind === 'Application') {
isAppOfAppsPattern = true;
break;
if (app.status?.resources) {
for (const resource of app.status.resources) {
if (resource.kind === 'Application') {
isAppOfAppsPattern = true;
break;
}
}
}
return {...app, isAppOfAppsPattern};
});
const filterResults = getFilterResults(applications, pref);
const filterResults = getAppFilterResults(processedApps, pref);
return {
filterResults,
filteredApps: filterResults.filter(
app => (search === '' || app.metadata.name.includes(search) || app.metadata.namespace.includes(search)) && Object.values(app.filterResult).every(val => val)
)
};
}
function filterApplicationSets(
appSets: models.ApplicationSet[],
pref: AppSetsListPreferences,
search: string
): {filteredApps: models.ApplicationSet[]; filterResults: ApplicationSetFilteredApp[]} {
const filterResults = getAppSetFilterResults(appSets, pref);
return {
filterResults,
filteredApps: filterResults.filter(
@@ -203,8 +221,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<HTMLDivElement>(null);
@@ -263,7 +281,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 application sets...'}
/>
<div className='keyboard-hint'>/</div>
{content && (
@@ -294,19 +312,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<ApplicationsToolbarProps> = ({applications, pref, ctx, healthBarPrefs}) => {
const ApplicationsToolbar: React.FC<ApplicationsToolbarProps> = ({applications, pref, ctx, healthBarPrefs, isListOfApplications}) => {
const {List, Summary, Tiles} = AppsListViewKey;
const query = useQuery();
return (
<React.Fragment key='app-list-tools'>
<SearchBar content={query.get('search')} apps={applications} ctx={ctx} />
<SearchBar content={query.get('search')} apps={applications} ctx={ctx} isListOfApplications={isListOfApplications} />
<Tooltip content='Toggle Health Status Bar'>
<button
className={`applications-list__accordion argo-button argo-button--base${healthBarPrefs.showHealthStatusBar ? '-o' : ''}`}
@@ -408,8 +427,7 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {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
@@ -425,7 +443,7 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
services.applications.get(appName, appNamespace, objectListKind, 'normal');
}
function onFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) {
function onAppFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) {
services.viewPreferences.updatePreferences({appList: newPref});
ctx.navigation.goto(
'.',
@@ -444,14 +462,28 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
);
}
function onAppSetFilterPrefChanged(ctx: ContextApis, newPref: AppSetsListPreferences) {
// Use appList since ViewPreferences shares preferences between apps and appsets
services.viewPreferences.updatePreferences({appList: newPref as AppsListPreferences});
ctx.navigation.goto(
'.',
{
health: newPref.healthFilter.join(','),
labels: newPref.labelsFilter.map(encodeURIComponent).join(',')
},
{replace: true}
);
}
function getPageTitle(view: string) {
const entityName = isListOfApplications ? 'Applications' : 'ApplicationSets';
switch (view) {
case List:
return 'Applications List';
return `${entityName} List`;
case Tiles:
return 'Applications Tiles';
return `${entityName} Tiles`;
case Summary:
return 'Applications Summary';
return `${entityName} Summary`;
}
return '';
}
@@ -469,7 +501,14 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {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}>
<DataLoader
input={pref.projectsFilter?.join(',')}
@@ -480,9 +519,8 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
<MockupList height={100} marginTop={30} />
</div>
)}>
{(applications: models.Application[]) => {
{(applications: models.AbstractApplication[]) => {
const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences);
const {filteredApps, filterResults} = filterApps(applications, pref, pref.search);
const handleCreatePanelClose = async () => {
const outsideDiv = document.querySelector('.sliding-panel__outside');
const closeButton = document.querySelector('.sliding-panel__close');
@@ -497,76 +535,290 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
ctx.navigation.goto('.', {new: null}, {replace: true});
}
};
return (
<React.Fragment>
<FlexTopBar
toolbar={{
tools: <ApplicationsToolbar applications={applications} pref={pref} ctx={ctx} healthBarPrefs={healthBarPrefs} />,
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})
}
]
}
}}
/>
<div className='applications-list'>
{applications.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? (
<EmptyState icon='argo-icon-application'>
<h4>No applications available to you just yet</h4>
<h5>Create new application to start managing resources in your cluster</h5>
<button
qe-id='applications-list-button-create-application'
className='argo-button argo-button--base'
onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})}, {replace: true})}>
Create application
</button>
</EmptyState>
) : (
<>
{ReactDOM.createPortal(
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{allpref => (
<ApplicationsFilter
apps={filterResults}
onChange={newPrefs => onFilterPrefChanged(ctx, newPrefs)}
pref={pref}
collapsed={allpref.hideSidebar}
/>
)}
</DataLoader>,
sidebarTarget?.current
)}
{(pref.view === 'summary' && <ApplicationsSummary applications={filteredApps} />) || (
if (isListOfApplications) {
// Applications path - fully type-safe
const apps = applications as models.Application[];
const {filteredApps, filterResults} = filterApplications(apps, pref, pref.search);
return (
<React.Fragment>
<FlexTopBar
toolbar={{
tools: (
<ApplicationsToolbar
applications={applications}
pref={pref}
ctx={ctx}
healthBarPrefs={healthBarPrefs}
isListOfApplications={isListOfApplications}
/>
),
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})
}
]
}
}}
/>
<div className='applications-list'>
{apps.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? (
<EmptyState icon='argo-icon-application'>
<h4>No applications available to you just yet</h4>
<h5>Create new application to start managing resources in your cluster</h5>
<button
qe-id='applications-list-button-create-application'
className='argo-button argo-button--base'
onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})}, {replace: true})}>
Create application
</button>
</EmptyState>
) : (
<>
{ReactDOM.createPortal(
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{allpref => (
<ApplicationsFilter
apps={filterResults}
onChange={newPrefs => onAppFilterPrefChanged(ctx, newPrefs)}
pref={pref}
collapsed={allpref.hideSidebar}
/>
)}
</DataLoader>,
sidebarTarget?.current
)}
{(pref.view === 'summary' && <ApplicationsSummary applications={filteredApps} />) || (
<Paginate
header={filteredApps.length > 1 && <AppsStatusBar applications={filteredApps} />}
showHeader={healthBarPrefs.showHealthStatusBar}
preferencesKey='applications-list'
page={pref.page}
emptyState={() => (
<EmptyState icon='fa fa-search'>
<h4>No matching applications found</h4>
<h5>
Change filter criteria or&nbsp;
<a
onClick={() => {
AppsListPreferences.clearFilters(pref);
onAppFilterPrefChanged(ctx, pref);
}}>
clear filters
</a>
</h5>
</EmptyState>
)}
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)
}
]}
data={filteredApps}
onPageChange={page => ctx.navigation.goto('.', {page})}>
{data =>
(pref.view === 'tiles' && (
<ApplicationTiles
applications={data}
syncApplication={(appName, appNamespace) =>
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
}
refreshApplication={refreshApp}
deleteApplication={(appName, appNamespace) =>
AppUtils.deleteApplication(appName, appNamespace, ctx)
}
/>
)) || (
<ApplicationsTable
applications={data}
syncApplication={(appName, appNamespace) =>
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
}
refreshApplication={refreshApp}
deleteApplication={(appName, appNamespace) =>
AppUtils.deleteApplication(appName, appNamespace, ctx)
}
/>
)
}
</Paginate>
)}
</>
)}
<ApplicationsSyncPanel
key='syncsPanel'
show={syncAppsInput}
hide={() => ctx.navigation.goto('.', {syncApps: null}, {replace: true})}
apps={filteredApps}
/>
<ApplicationsRefreshPanel
key='refreshPanel'
show={refreshAppsInput}
hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})}
apps={filteredApps}
/>
</div>
<DataLoader
load={() =>
observableQuery$.pipe(
mergeMap(params => {
const syncApp = params.get('syncApp');
const appNamespace = params.get('appNamespace');
return (syncApp && from(services.applications.get(syncApp, appNamespace, objectListKind))) || from([null]);
})
)
}>
{app => (
<ApplicationSyncPanel
key='syncPanel'
application={app}
selectedResource={'all'}
hide={() => ctx.navigation.goto('.', {syncApp: null}, {replace: true})}
/>
)}
</DataLoader>
<SlidingPanel
isShown={!!appInput}
onClose={() => handleCreatePanelClose()}
header={
<div>
<button
qe-id='applications-list-button-create'
className='argo-button argo-button--base'
disabled={isAppCreatePending}
onClick={() => createApi && createApi.submitForm(null)}>
<Spinner show={isAppCreatePending} style={{marginRight: '5px'}} />
Create
</button>{' '}
<button
qe-id='applications-list-button-cancel'
onClick={() => ctx.navigation.goto('.', {new: null}, {replace: true})}
className='argo-button argo-button--base-o'>
Cancel
</button>
</div>
}>
{appInput && (
<ApplicationCreatePanel
getFormApi={api => {
setCreateApi(api);
}}
createApp={async app => {
setAppCreatePending(true);
try {
await services.applications.create(app);
ctx.navigation.goto('.', {new: null}, {replace: true});
} catch (e) {
ctx.notifications.show({
content: <ErrorNotification title='Unable to create application' e={e} />,
type: NotificationType.Error
});
} finally {
setAppCreatePending(false);
}
}}
app={appInput}
onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})}
/>
)}
</SlidingPanel>
</React.Fragment>
);
} else {
// ApplicationSets path - fully type-safe
const appSets = applications as models.ApplicationSet[];
const appSetPref: AppSetsListPreferences = {
labelsFilter: pref.labelsFilter,
healthFilter: pref.healthFilter,
showFavorites: pref.showFavorites,
favoritesAppList: pref.favoritesAppList,
view: pref.view,
hideFilters: pref.hideFilters,
statusBarView: pref.statusBarView,
annotationsFilter: pref.annotationsFilter
};
const {filteredApps, filterResults} = filterApplicationSets(appSets, appSetPref, pref.search);
return (
<React.Fragment>
<FlexTopBar
toolbar={{
tools: (
<ApplicationsToolbar
applications={applications}
pref={pref}
ctx={ctx}
healthBarPrefs={healthBarPrefs}
isListOfApplications={isListOfApplications}
/>
),
actionMenu: {
items: [] // No action menu for ApplicationSets yet
}
}}
/>
<div className='applications-list'>
{appSets.length === 0 && (pref.labelsFilter || []).length === 0 ? (
<EmptyState icon='argo-icon-application'>
<h4>No ApplicationSets available to you just yet</h4>
<h5>ApplicationSets will appear here once created</h5>
</EmptyState>
) : (
<>
{ReactDOM.createPortal(
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{allpref => (
<AppSetsFilter
apps={filterResults}
onChange={newPrefs => onAppSetFilterPrefChanged(ctx, newPrefs)}
pref={appSetPref}
collapsed={allpref.hideSidebar}
/>
)}
</DataLoader>,
sidebarTarget?.current
)}
<Paginate
header={filteredApps.length > 1 && <ApplicationsStatusBar applications={filteredApps} />}
header={filteredApps.length > 1 && <AppSetsStatusBar appSets={filteredApps} />}
showHeader={healthBarPrefs.showHealthStatusBar}
preferencesKey='applications-list'
page={pref.page}
emptyState={() => (
<EmptyState icon='fa fa-search'>
<h4>No matching applications found</h4>
<h4>No matching application sets found</h4>
<h5>
Change filter criteria or&nbsp;
<a
onClick={() => {
AppsListPreferences.clearFilters(pref);
onFilterPrefChanged(ctx, pref);
AppSetsListPreferences.clearFilters(appSetPref);
onAppSetFilterPrefChanged(ctx, appSetPref);
}}>
clear filters
</a>
@@ -581,11 +833,6 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
{
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)
}
]}
data={filteredApps}
@@ -594,110 +841,26 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
(pref.view === 'tiles' && (
<ApplicationTiles
applications={data}
syncApplication={(appName, appNamespace) =>
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
}
refreshApplication={refreshApp}
deleteApplication={(appName, appNamespace) =>
AppUtils.deleteApplication(appName, appNamespace, ctx)
}
syncApplication={() => {}}
refreshApplication={() => {}}
deleteApplication={() => {}}
/>
)) || (
<ApplicationsTable
applications={data}
syncApplication={(appName, appNamespace) =>
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
}
refreshApplication={refreshApp}
deleteApplication={(appName, appNamespace) =>
AppUtils.deleteApplication(appName, appNamespace, ctx)
}
syncApplication={() => {}}
refreshApplication={() => {}}
deleteApplication={() => {}}
/>
)
}
</Paginate>
)}
</>
)}
<ApplicationsSyncPanel
key='syncsPanel'
show={syncAppsInput}
hide={() => ctx.navigation.goto('.', {syncApps: null}, {replace: true})}
apps={filteredApps}
/>
<ApplicationsRefreshPanel
key='refreshPanel'
show={refreshAppsInput}
hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})}
apps={filteredApps}
/>
</div>
<DataLoader
load={() =>
observableQuery$.pipe(
mergeMap(params => {
const syncApp = params.get('syncApp');
const appNamespace = params.get('appNamespace');
return (syncApp && from(services.applications.get(syncApp, appNamespace, objectListKind))) || from([null]);
})
)
}>
{app => (
<ApplicationSyncPanel
key='syncPanel'
application={app}
selectedResource={'all'}
hide={() => ctx.navigation.goto('.', {syncApp: null}, {replace: true})}
/>
)}
</DataLoader>
<SlidingPanel
isShown={!!appInput}
onClose={() => handleCreatePanelClose()} //Separate handling for outside click.
header={
<div>
<button
qe-id='applications-list-button-create'
className='argo-button argo-button--base'
disabled={isAppCreatePending}
onClick={() => createApi && createApi.submitForm(null)}>
<Spinner show={isAppCreatePending} style={{marginRight: '5px'}} />
Create
</button>{' '}
<button
qe-id='applications-list-button-cancel'
onClick={() => ctx.navigation.goto('.', {new: null}, {replace: true})}
className='argo-button argo-button--base-o'>
Cancel
</button>
</div>
}>
{appInput && (
<ApplicationCreatePanel
getFormApi={api => {
setCreateApi(api);
}}
createApp={async app => {
setAppCreatePending(true);
try {
await services.applications.create(app);
ctx.navigation.goto('.', {new: null}, {replace: true});
} catch (e) {
ctx.notifications.show({
content: <ErrorNotification title='Unable to create application' e={e} />,
type: NotificationType.Error
});
} finally {
setAppCreatePending(false);
}
}}
app={appInput}
onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})}
/>
)}
</SlidingPanel>
</React.Fragment>
);
</>
)}
</div>
</React.Fragment>
);
}
}}
</DataLoader>
</Page>

View File

@@ -3,15 +3,18 @@ import * as React from 'react';
import {COLORS} from '../../../shared/components';
import {Consumer} from '../../../shared/context';
import * as models from '../../../shared/models';
import {getAppSetHealthStatus} from '../utils';
import './applications-status-bar.scss';
export interface ApplicationsStatusBarProps {
applications: models.Application[];
interface Reading {
name: string;
value: number;
color: string;
}
export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps) => {
const readings = [
function getAppReadings(applications: models.Application[]): Reading[] {
return [
{
name: 'Healthy',
value: applications.filter(app => app.status.health.status === 'Healthy').length,
@@ -43,11 +46,38 @@ export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps
color: COLORS.health.unknown
}
];
}
function getAppSetReadings(appSets: models.ApplicationSet[]): Reading[] {
return [
{
name: 'Healthy',
value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Healthy').length,
color: COLORS.health.healthy
},
{
name: 'Progressing',
value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Progressing').length,
color: COLORS.health.progressing
},
{
name: 'Degraded',
value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Degraded').length,
color: COLORS.health.degraded
},
{
name: 'Unknown',
value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Unknown').length,
color: COLORS.health.unknown
}
];
}
function StatusBarRenderer({readings}: {readings: Reading[]}) {
// 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));
const sortedReadings = [...readings].sort((a, b) => (a.value < b.value ? 1 : a.value === b.value ? (a.name > b.name ? 1 : -1) : -1));
const totalItems = readings.reduce((total, i) => {
const totalItems = sortedReadings.reduce((total, i) => {
return total + i.value;
}, 0);
@@ -57,9 +87,9 @@ export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps
<>
{totalItems > 1 && (
<div className='status-bar'>
{readings &&
readings.length > 1 &&
readings.map((item, i) => {
{sortedReadings &&
sortedReadings.length > 1 &&
sortedReadings.map((item, i) => {
if (item.value > 0) {
return (
<div className='status-bar__segment' style={{backgroundColor: item.color, width: (item.value / totalItems) * 100 + '%'}} key={i}>
@@ -76,4 +106,35 @@ export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps
)}
</Consumer>
);
}
export interface AppsStatusBarProps {
applications: models.Application[];
}
export const AppsStatusBar = ({applications}: AppsStatusBarProps) => {
if (!applications || applications.length === 0) {
return null;
}
return <StatusBarRenderer readings={getAppReadings(applications)} />;
};
export interface AppSetsStatusBarProps {
appSets: models.ApplicationSet[];
}
export const AppSetsStatusBar = ({appSets}: AppSetsStatusBarProps) => {
if (!appSets || appSets.length === 0) {
return null;
}
return <StatusBarRenderer readings={getAppSetReadings(appSets)} />;
};
// Legacy wrapper for backwards compatibility (callers should migrate to AppsStatusBar or AppSetsStatusBar)
export interface ApplicationsStatusBarProps {
applications: models.Application[];
}
export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps) => {
return <AppsStatusBar applications={applications} />;
};

View File

@@ -1,20 +1,18 @@
import {DataLoader, DropDownMenu, Tooltip} from 'argo-ui';
import {DataLoader} from 'argo-ui';
import * as React from 'react';
import Moment from 'react-moment';
import {Key, KeybindingContext, useNav} from 'argo-ui/v2';
import {Cluster} from '../../../shared/components';
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 {ApplicationsLabels} from './applications-labels';
import {ApplicationsSource} from './applications-source';
import {isApp} from '../utils';
import {services} from '../../../shared/services';
import {ApplicationTableRow} from './application-table-row';
import {AppSetTableRow} from './appset-table-row';
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;
@@ -48,149 +46,26 @@ export const ApplicationsTable = (props: {
<Consumer>
{ctx => (
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{pref => {
const favList = pref.appList.favoritesAppList || [];
return (
<div className='applications-table argo-table-list argo-table-list--clickable'>
{props.applications.map((app, i) => {
return (
<div
key={AppUtils.appInstanceName(app)}
className={`argo-table-list__row
applications-list__entry applications-list__entry--health-${app.status.health.status} ${selectedApp === i ? 'applications-tiles__selected' : ''}`}>
<div
className={`row applications-list__table-row ${app.status.sourceHydrator?.currentOperation ? 'applications-table-row--with-hydrator' : ''}`}
onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {}, {event: e})}>
<div className='columns small-4'>
<div className='row'>
<div className=' columns small-2'>
<div>
<Tooltip content={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
<button
onClick={e => {
e.stopPropagation();
favList?.includes(app.metadata.name)
? favList.splice(favList.indexOf(app.metadata.name), 1)
: favList.push(app.metadata.name);
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
}}>
<i
className={favList?.includes(app.metadata.name) ? 'fas fa-star' : 'far fa-star'}
style={{
cursor: 'pointer',
marginRight: '7px',
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
}}
/>
</button>
</Tooltip>
<ApplicationURLs urls={app.status.summary.externalURLs} />
</div>
</div>
<div className='show-for-xxlarge columns small-4'>Project:</div>
<div className='columns small-12 xxlarge-6'>{app.spec.project}</div>
</div>
<div className='row'>
<div className=' columns small-2' />
<div className='show-for-xxlarge columns small-4'>Name:</div>
<div className='columns small-12 xxlarge-6'>
<Tooltip
content={
<>
{app.metadata.name}
<br />
<Moment fromNow={true} ago={true}>
{app.metadata.creationTimestamp}
</Moment>
</>
}>
<span>{app.metadata.name}</span>
</Tooltip>
{/* External link icon for managed-by-url */}
{(() => {
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
return (
<button
onClick={e => {
e.stopPropagation();
if (linkInfo.isExternal) {
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
} else {
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
}
}}
style={{marginLeft: '0.5em'}}
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(app) || 'none'}`}>
<i className='fa fa-external-link-alt' />
</button>
);
})()}
</div>
</div>
</div>
<div className='columns small-6'>
<div className='row'>
<div className='show-for-xxlarge columns small-2'>Source:</div>
<div className='columns small-12 xxlarge-10 applications-table-source' style={{position: 'relative'}}>
<div className='applications-table-source__link'>
<ApplicationsSource source={getAppDefaultSource(app)} />
</div>
<div className='applications-table-source__labels'>
<ApplicationsLabels app={app} />
</div>
</div>
</div>
<div className='row'>
<div className='show-for-xxlarge columns small-2'>Destination:</div>
<div className='columns small-12 xxlarge-10'>
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />/{app.spec.destination.namespace}
</div>
</div>
</div>
<div className='columns small-2'>
<AppUtils.HealthStatusIcon state={app.status.health} /> <span>{app.status.health.status}</span> <br />
{app.status.sourceHydrator?.currentOperation && (
<>
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
<span>{app.status.sourceHydrator.currentOperation.phase}</span> <br />
</>
)}
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} />
<span>{app.status.sync.status}</span> <OperationState app={app} quiet={true} />
<DropDownMenu
anchor={() => (
<button className='argo-button argo-button--light argo-button--lg argo-button--short'>
<i className='fa fa-ellipsis-v' />
</button>
)}
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)
}
]}
/>
</div>
</div>
</div>
);
})}
</div>
);
}}
{pref => (
<div className='applications-table argo-table-list argo-table-list--clickable'>
{props.applications.map((app, i) =>
isApp(app) ? (
<ApplicationTableRow
key={AppUtils.appInstanceName(app)}
app={app as models.Application}
selected={selectedApp === i}
pref={pref}
ctx={ctx}
syncApplication={props.syncApplication}
refreshApplication={props.refreshApplication}
deleteApplication={props.deleteApplication}
/>
) : (
<AppSetTableRow key={AppUtils.appInstanceName(app)} appSet={app as models.ApplicationSet} selected={selectedApp === i} pref={pref} ctx={ctx} />
)
)}
</div>
)}
</DataLoader>
)}
</Consumer>

View File

@@ -1,19 +1,18 @@
import {DataLoader, Tooltip} from 'argo-ui';
import * as classNames from 'classnames';
import {DataLoader} from 'argo-ui';
import * as React from 'react';
import {Key, KeybindingContext, NumKey, NumKeyToNumber, NumPadKey, useNav} from 'argo-ui/v2';
import {Cluster} from '../../../shared/components';
import {Consumer, Context, AuthSettingsCtx} from '../../../shared/context';
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 {isApp} from '../utils';
import {services} from '../../../shared/services';
import {ApplicationTile} from './application-tile';
import {AppSetTile} from './appset-tile';
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;
@@ -50,10 +49,9 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat
const [selectedApp, navApp, reset] = useNav(applications.length);
const ctxh = React.useContext(Context);
const appRef = {ref: React.useRef(null), set: false};
const firstTileRef = React.useRef<HTMLDivElement>(null);
const appContainerRef = React.useRef(null);
const appsPerRow = useItemsPerContainer(appRef.ref, appContainerRef);
const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
const appsPerRow = useItemsPerContainer(firstTileRef, appContainerRef);
const {useKeybinding} = React.useContext(KeybindingContext);
@@ -98,269 +96,39 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat
return navApp(NumKeyToNumber(n));
}
});
return (
<Consumer>
{ctx => (
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{pref => {
const favList = pref.appList.favoritesAppList || [];
return (
<div className='applications-tiles argo-table-list argo-table-list--clickable' ref={appContainerRef}>
{applications.map((app, i) => {
const source = getAppDefaultSource(app);
const isOci = source?.repoURL?.startsWith('oci://');
const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown';
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
return (
<div
key={AppUtils.appInstanceName(app)}
ref={appRef.set ? null : appRef.ref}
className={`argo-table-list__row applications-list__entry applications-list__entry--health-${app.status.health.status} ${
selectedApp === i ? 'applications-tiles__selected' : ''
}`}>
<div
className='row applications-tiles__wrapper'
onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {view: pref.appDetails.view}, {event: e})}>
<div
className={`columns small-12 applications-list__info qe-applications-list-${AppUtils.appInstanceName(
app
)} applications-tiles__item`}>
<div className='row '>
<div className={app.status.summary.externalURLs?.length > 0 ? 'columns small-10' : 'columns small-11'}>
<i
className={
'icon argo-icon-' + (source?.chart != null ? 'helm' : isOci ? 'oci applications-tiles__item__small' : 'git')
}
/>
<Tooltip content={AppUtils.appInstanceName(app)}>
<span className='applications-list__title'>
{AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)}
</span>
</Tooltip>
</div>
<div className={app.status.summary.externalURLs?.length > 0 ? 'columns small-2' : 'columns small-1'}>
<div className='applications-list__external-link'>
<ApplicationURLs urls={app.status.summary.externalURLs} />
<button
onClick={e => {
e.stopPropagation();
if (linkInfo.isExternal) {
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
} else {
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
}
}}
title={getManagedByURL(app) ? `Managed by: ${getManagedByURL(app)}` : 'Open application'}>
<i className='fa fa-external-link-alt' />
</button>
<button
title={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
className='large-text-height'
onClick={e => {
e.stopPropagation();
favList?.includes(app.metadata.name)
? favList.splice(favList.indexOf(app.metadata.name), 1)
: favList.push(app.metadata.name);
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
}}>
<i
className={favList?.includes(app.metadata.name) ? 'fas fa-star fa-lg' : 'far fa-star fa-lg'}
style={{
cursor: 'pointer',
margin: '-1px 0px 0px 7px',
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
}}
/>
</button>
</div>
</div>
</div>
<div className='row'>
<div className='columns small-3' title='Project:'>
Project:
</div>
<div className='columns small-9'>{app.spec.project}</div>
</div>
<div className='row'>
<div className='columns small-3' title='Labels:'>
Labels:
</div>
<div className='columns small-9'>
<Tooltip
zIndex={4}
content={
<div>
{Object.keys(app.metadata.labels || {})
.map(label => ({label, value: app.metadata.labels[label]}))
.map(item => (
<div key={item.label}>
{item.label}={item.value}
</div>
))}
</div>
}>
<span>
{Object.keys(app.metadata.labels || {})
.map(label => `${label}=${app.metadata.labels[label]}`)
.join(', ')}
</span>
</Tooltip>
</div>
</div>
<div className='row'>
<div className='columns small-3' title='Annotations:'>
Annotations:
</div>
<div className='columns small-9'>
<Tooltip
zIndex={4}
content={
<div>
{Object.keys(app.metadata.annotations || {})
.map(annotation => ({label: annotation, value: app.metadata.annotations[annotation]}))
.map(item => (
<div key={item.label}>
{item.label}={item.value}
</div>
))}
</div>
}>
<span>
{Object.keys(app.metadata.annotations || {})
.map(annotation => `${annotation}=${app.metadata.annotations[annotation]}`)
.join(', ')}
</span>
</Tooltip>
</div>
</div>
<div className='row'>
<div className='columns small-3' title='Status:'>
Status:
</div>
<div className='columns small-9' qe-id='applications-tiles-health-status'>
<AppUtils.HealthStatusIcon state={app.status.health} /> {app.status.health.status}
&nbsp;
{app.status.sourceHydrator?.currentOperation && (
<>
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
{app.status.sourceHydrator.currentOperation.phase}
&nbsp;
</>
)}
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} /> {app.status.sync.status}
&nbsp;
<OperationState app={app} quiet={true} />
</div>
</div>
<div className='row'>
<div className='columns small-3' title='Repository:'>
Repository:
</div>
<div className='columns small-9'>
<Tooltip content={source?.repoURL || ''} zIndex={4}>
<span>{source?.repoURL}</span>
</Tooltip>
</div>
</div>
<div className='row'>
<div className='columns small-3' title='Target Revision:'>
Target Revision:
</div>
<div className='columns small-9'>{targetRevision}</div>
</div>
{source?.path && (
<div className='row'>
<div className='columns small-3' title='Path:'>
Path:
</div>
<div className='columns small-9'>{source?.path}</div>
</div>
)}
{source?.chart && (
<div className='row'>
<div className='columns small-3' title='Chart:'>
Chart:
</div>
<div className='columns small-9'>{source?.chart}</div>
</div>
)}
<div className='row'>
<div className='columns small-3' title='Destination:'>
Destination:
</div>
<div className='columns small-9'>
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />
</div>
</div>
<div className='row'>
<div className='columns small-3' title='Namespace:'>
Namespace:
</div>
<div className='columns small-9'>{app.spec.destination.namespace}</div>
</div>
<div className='row'>
<div className='columns small-3' title='Age:'>
Created At:
</div>
<div className='columns small-9'>{AppUtils.formatCreationTimestamp(app.metadata.creationTimestamp)}</div>
</div>
{app.status.operationState && (
<div className='row'>
<div className='columns small-3' title='Last sync:'>
Last Sync:
</div>
<div className='columns small-9'>
{AppUtils.formatCreationTimestamp(app.status.operationState.finishedAt || app.status.operationState.startedAt)}
</div>
</div>
)}
<div className='row applications-tiles__actions'>
<div className='columns applications-list__entry--actions'>
<a
className='argo-button argo-button--base'
qe-id='applications-tiles-button-sync'
onClick={e => {
e.stopPropagation();
syncApplication(app.metadata.name, app.metadata.namespace);
}}>
<i className='fa fa-sync' /> Sync
</a>
&nbsp;
<Tooltip className='custom-tooltip' content={'Refresh'}>
<a
className='argo-button argo-button--base'
qe-id='applications-tiles-button-refresh'
{...AppUtils.refreshLinkAttrs(app)}
onClick={e => {
e.stopPropagation();
refreshApplication(app.metadata.name, app.metadata.namespace);
}}>
<i className={classNames('fa fa-redo', {'status-icon--spin': AppUtils.isAppRefreshing(app)})} />{' '}
<span className='show-for-xxlarge'>Refresh</span>
</a>
</Tooltip>
&nbsp;
<Tooltip className='custom-tooltip' content={'Delete'}>
<a
className='argo-button argo-button--base'
qe-id='applications-tiles-button-delete'
onClick={e => {
e.stopPropagation();
deleteApplication(app.metadata.name, app.metadata.namespace);
}}>
<i className='fa fa-times-circle' /> <span className='show-for-xxlarge'>Delete</span>
</a>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
}}
{pref => (
<div className='applications-tiles argo-table-list argo-table-list--clickable' ref={appContainerRef}>
{applications.map((app, i) =>
isApp(app) ? (
<ApplicationTile
key={AppUtils.appInstanceName(app)}
app={app as models.Application}
selected={selectedApp === i}
pref={pref}
ctx={ctx}
tileRef={i === 0 ? firstTileRef : undefined}
syncApplication={syncApplication}
refreshApplication={refreshApplication}
deleteApplication={deleteApplication}
/>
) : (
<AppSetTile
key={AppUtils.appInstanceName(app)}
appSet={app as models.ApplicationSet}
selected={selectedApp === i}
pref={pref}
ctx={ctx}
tileRef={i === 0 ? firstTileRef : undefined}
/>
)
)}
</div>
)}
</DataLoader>
)}
</Consumer>

View File

@@ -0,0 +1,100 @@
import {Tooltip} from 'argo-ui';
import * as React from 'react';
import Moment from 'react-moment';
import {ContextApis} from '../../../shared/context';
import * as models from '../../../shared/models';
import * as AppUtils from '../utils';
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
import {services} from '../../../shared/services';
import {ViewPreferences} from '../../../shared/services';
export interface AppSetTableRowProps {
appSet: models.ApplicationSet;
selected: boolean;
pref: ViewPreferences;
ctx: ContextApis;
}
export const AppSetTableRow = ({appSet, selected, pref, ctx}: AppSetTableRowProps) => {
const favList = pref.appList.favoritesAppList || [];
const healthStatus = getAppSetHealthStatus(appSet);
const linkInfo = getApplicationLinkURL(appSet, ctx.baseHref);
const handleFavoriteToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (favList?.includes(appSet.metadata.name)) {
favList.splice(favList.indexOf(appSet.metadata.name), 1);
} else {
favList.push(appSet.metadata.name);
}
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
};
const handleExternalLinkClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (linkInfo.isExternal) {
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
} else {
ctx.navigation.goto(`/${AppUtils.getAppUrl(appSet)}`);
}
};
return (
<div className={`argo-table-list__row applications-list__entry applications-list__entry--health-${healthStatus} ${selected ? 'applications-tiles__selected' : ''}`}>
<div className='row applications-list__table-row' onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(appSet)}`, {}, {event: e})}>
{/* First column: Favorite, Kind, Name */}
<div className='columns small-4'>
<div className='row'>
<div className='columns small-2'>
<div>
<Tooltip content={favList?.includes(appSet.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
<button onClick={handleFavoriteToggle}>
<i
className={favList?.includes(appSet.metadata.name) ? 'fas fa-star' : 'far fa-star'}
style={{
cursor: 'pointer',
marginRight: '7px',
color: favList?.includes(appSet.metadata.name) ? '#FFCE25' : '#8fa4b1'
}}
/>
</button>
</Tooltip>
</div>
</div>
<div className='show-for-xxlarge columns small-4'>Kind:</div>
<div className='columns small-12 xxlarge-6'>ApplicationSet</div>
</div>
<div className='row'>
<div className='columns small-2' />
<div className='show-for-xxlarge columns small-4'>Name:</div>
<div className='columns small-12 xxlarge-6'>
<Tooltip
content={
<>
{appSet.metadata.name}
<br />
<Moment fromNow={true} ago={true}>
{appSet.metadata.creationTimestamp}
</Moment>
</>
}>
<span>{appSet.metadata.name}</span>
</Tooltip>
<button
onClick={handleExternalLinkClick}
style={{marginLeft: '0.5em'}}
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(appSet) || 'none'}`}>
<i className='fa fa-external-link-alt' />
</button>
</div>
</div>
</div>
{/* Status column (takes remaining space since no Source/Destination for AppSets) */}
<div className='columns small-8'>
<AppUtils.HealthStatusIcon state={{status: healthStatus, message: ''}} /> <span>{healthStatus}</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,137 @@
import {Tooltip} from 'argo-ui';
import * as React from 'react';
import {ContextApis, AuthSettingsCtx} from '../../../shared/context';
import * as models from '../../../shared/models';
import * as AppUtils from '../utils';
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
import {services} from '../../../shared/services';
import {ViewPreferences} from '../../../shared/services';
export interface AppSetTileProps {
appSet: models.ApplicationSet;
selected: boolean;
pref: ViewPreferences;
ctx: ContextApis;
tileRef?: React.RefObject<HTMLDivElement>;
}
export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTileProps) => {
const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
const favList = pref.appList.favoritesAppList || [];
const linkInfo = getApplicationLinkURL(appSet, ctx.baseHref);
const healthStatus = getAppSetHealthStatus(appSet);
const handleFavoriteToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (favList?.includes(appSet.metadata.name)) {
favList.splice(favList.indexOf(appSet.metadata.name), 1);
} else {
favList.push(appSet.metadata.name);
}
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
};
const handleExternalLinkClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (linkInfo.isExternal) {
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
} else {
ctx.navigation.goto(`/${AppUtils.getAppUrl(appSet)}`);
}
};
return (
<div
ref={tileRef}
className={`argo-table-list__row applications-list__entry applications-list__entry--health-${healthStatus} ${selected ? 'applications-tiles__selected' : ''}`}>
<div className='row applications-tiles__wrapper' onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(appSet)}`, {view: pref.appDetails.view}, {event: e})}>
<div className={`columns small-12 applications-list__info qe-applications-list-${AppUtils.appInstanceName(appSet)} applications-tiles__item`}>
{/* Header row with icon, title, and action buttons */}
<div className='row'>
<div className='columns small-11'>
<i className='icon argo-icon-git' />
<Tooltip content={AppUtils.appInstanceName(appSet)}>
<span className='applications-list__title'>{AppUtils.appQualifiedName(appSet, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)}</span>
</Tooltip>
</div>
<div className='columns small-1'>
<div className='applications-list__external-link'>
<button onClick={handleExternalLinkClick} title={getManagedByURL(appSet) ? `Managed by: ${getManagedByURL(appSet)}` : 'Open application'}>
<i className='fa fa-external-link-alt' />
</button>
<button
title={favList?.includes(appSet.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
className='large-text-height'
onClick={handleFavoriteToggle}>
<i
className={favList?.includes(appSet.metadata.name) ? 'fas fa-star fa-lg' : 'far fa-star fa-lg'}
style={{
cursor: 'pointer',
margin: '-1px 0px 0px 7px',
color: favList?.includes(appSet.metadata.name) ? '#FFCE25' : '#8fa4b1'
}}
/>
</button>
</div>
</div>
</div>
{/* Labels row */}
<div className='row'>
<div className='columns small-3' title='Labels:'>
Labels:
</div>
<div className='columns small-9'>
<Tooltip
zIndex={4}
content={
<div>
{Object.keys(appSet.metadata.labels || {})
.map(label => ({label, value: appSet.metadata.labels[label]}))
.map(item => (
<div key={item.label}>
{item.label}={item.value}
</div>
))}
</div>
}>
<span>
{Object.keys(appSet.metadata.labels || {})
.map(label => `${label}=${appSet.metadata.labels[label]}`)
.join(', ')}
</span>
</Tooltip>
</div>
</div>
{/* Status row */}
<div className='row'>
<div className='columns small-3' title='Status:'>
Status:
</div>
<div className='columns small-9' qe-id='applications-tiles-health-status'>
<AppUtils.HealthStatusIcon state={{status: healthStatus, message: ''}} /> {healthStatus}
</div>
</div>
{/* Applications count row */}
<div className='row'>
<div className='columns small-3' title='Applications:'>
Applications:
</div>
<div className='columns small-9'>{appSet.status?.resourcesCount ?? appSet.status?.resources?.length ?? 0}</div>
</div>
{/* Created At row */}
<div className='row'>
<div className='columns small-3' title='Age:'>
Created At:
</div>
<div className='columns small-9'>{AppUtils.formatCreationTimestamp(appSet.metadata.creationTimestamp)}</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -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;
}

View File

@@ -1170,5 +1170,6 @@ export interface ApplicationSet extends AbstractApplication {
targetRevisions?: string[];
}>;
resources?: ApplicationSetResource[];
resourcesCount?: number;
};
}

View File

@@ -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 {